fix(runtime): honor hook body timeoutMs so nested cross-object writes aren't clamped to 250ms (#1867)#3232
Merged
os-zhuang merged 3 commits intoJul 18, 2026
Conversation
… aren't clamped to 250ms (#1867) Nested cross-object writes from a hook (`ctx.api.object('parent').update(...)` from a child's afterInsert/afterUpdate) once crashed the QuickJS sandbox with `memory access out of bounds` — the old single-suspended-asyncify host-call model could not unwind the WASM stack twice. That crash is already fixed on main by the deferred-promise + pump host-call model (host calls no longer asyncify; each invocation gets its own isolated VM), so any depth of nested re-entrancy now composes safely. The remaining reliability blocker was the timeout: `resolveTimeout` folded the engine default (250ms for hooks) straight into `Math.min(...)`, so it always dominated. A hook body that declared a larger `timeoutMs` (the spec allows up to 30_000ms via `ScriptBody.timeoutMs`) to give a legitimate nested-write rollup room to settle was silently clamped back to 250ms and killed mid-flight — a declared-but-unenforced knob (AGENTS.md Prime Directive #10) that pushed authors toward denormalized hand-maintained rollups. The engine default is now a FALLBACK used only when no explicit timeout is supplied, not a hard ceiling. An explicit `body.timeoutMs` (and/or an enclosing hook/action timeout via opts) is honored; when both are present the smaller wins. Unspecified bodies still get the 250ms hook / 5000ms action default, and a body may still lower its own timeout below the default. Tests: - quickjs-runner.test.ts — nested sandbox re-entrancy (single, 4-level chain, concurrent fan-out) does not crash; timeout resolution honors a body's declared timeoutMs above the default, still applies the default when unspecified, and still lets a body lower it. - nested-write.integration.test.ts — real ObjectQL engine + sandbox: a child afterInsert/afterUpdate hook rolls its total up to the parent (parent also has a hook → real nested VM) without crashing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 16 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
#1867) Add a "Nested cross-object writes" note to the hook-bodies guide: a body may write other objects (child afterInsert/afterUpdate → parent update), the target's own hooks fire in a fresh sandbox VM, it composes to any depth, and a deeper or slower rollup chain should declare a larger timeoutMs (up to 30s) rather than rely on the 250ms single-write default. Documents the capability the runtime now delivers instead of the denormalized workaround. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP
…ollup (#1867) Wire the real ObjectQL engine to the real SqlDriver (better-sqlite3, on-disk) and drive the expense-template rollup pattern through the hook → sandbox → nested-write path: an expense_line afterInsert/afterUpdate hook recomputes and writes expense_report.total_amount. Confirms the child→parent nested write lands the correct total on a real database with no `memory access out of bounds` crash — the platform limit the templates' CHARTERs documented as forcing denormalized workarounds. Scoped to insert/update (where the hook can resolve the child FK); a comment notes delete-safe aggregate rollups belong to the native `summary` field, since an afterDelete hook receives only `{ id, options }` with no pre-image of the deleted row's FK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP
os-zhuang
marked this pull request as ready for review
July 18, 2026 16:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景
#1867(P0):子对象的
afterInsert/afterUpdate钩子里做嵌套跨对象写入(ctx.api.object('parent').update(...))会让 QuickJS 沙箱崩溃 ——memory access out of bounds。这条限制迫使五个模板把 header/rollup 字段改成手工维护的反规范化写法。关键发现
崩溃本身在
main上已经被修复了。 现在的沙箱采用 deferred-promise + pump 的宿主调用模型(host 调用不再走 asyncify,每次调用都跑在各自独立的newAsyncContext()VM 里),所以 asyncify「同一栈不能二次展开」的限制不再触发。我用真实的ObjectQL引擎 + 真实QuickJSScriptRunner复现了 issue 的场景(子对象钩子写父对象、且父对象自身也带钩子 → 真正的嵌套 VM),4 层嵌套写入链约 65ms 完成,无崩溃、无超时。issue 复现所用的9.5.1早于这次重构。真正遗留的可靠性瓶颈是超时钳制。
resolveTimeout把引擎默认值(钩子 250ms)直接塞进了Math.min(...),于是对钩子它永远是最小值:作者按 spec(ScriptBody.timeoutMs允许最大 30_000ms)声明了更大的timeoutMs、想给嵌套 rollup 留出时间,却被无声钳回 250ms 并在执行中途被 kill。这是一处「声明了但未强制执行」的缺口(AGENTS.md Prime Directive #10),正是把作者推向反规范化写法的那根稻草。改动
packages/runtime/src/sandbox/quickjs-runner.ts—resolveTimeout:body.timeoutMs(以及经opts传入的外层 hook/action 超时)会被尊重;两者都存在时取较小者 —— 与 spec「smaller of this and the enclosing hook/action timeout wins」一致。测试
quickjs-runner.test.ts新增 6 个用例:timeoutMs(600ms 的宿主调用在 5000ms 预算内完成);未声明时仍套用默认;仍可调低。nested-write.integration.test.ts:真实ObjectQL引擎 + 沙箱,子对象afterInsert/afterUpdate钩子把总额 rollup 到父对象(父对象也带钩子 → 真正的嵌套 VM),无崩溃,得到正确的反规范化总额。@objectstack/runtime全量 564 个测试通过;tsc --noEmit、eslint均通过。附带 changeset(patch)。关于
ctx.services.dataissue 提到「
ctx.services.data在沙箱里是 undefined(静默 no-op)」。这一项刻意不改:沙箱内数据访问的既定契约是ctx.api(带 capability 门禁);再暴露一个ctx.services.data会形成第二套方言、并绕过 capability 门禁(违反 Prime Directive #5/#12)。既然ctx.api的嵌套写入现在可用,这个「替代方案」也就不需要了。沙箱里访问ctx.services会明确抛错,而非静默吞掉写入。影响
嵌套跨对象写入(「子记录变化时更新父记录」)现在既安全又可靠,header/rollup 字段不再需要手工维护的反规范化 workaround。
🤖 Generated with Claude Code
Generated by Claude Code